defmodule Credo.Check.Refactor.ModuleDependencies do
  use Credo.Check,
    id: "EX4017",
    base_priority: :normal,
    tags: [:controversial],
    param_defaults: [
      max_deps: 10,
      dependency_namespaces: [],
      excluded_namespaces: [],
      excluded_paths: [~r"/test/", ~r"^test/"]
    ],
    explanations: [
      check: """
      This module might be doing too much. Consider limiting the number of
      module dependencies.

      As always: This is just a suggestion. Check the configuration options for
      tweaking or disabling this check.
      """,
      params: [
        max_deps: "Maximum number of module dependencies.",
        dependency_namespaces: "List of dependency namespaces to include in this check",
        excluded_namespaces: "List of namespaces to exclude from this check",
        excluded_paths: "List of paths or regex to exclude from this check"
      ]
    ]

  alias Credo.Code.Module
  alias Credo.Code.Name

  @doc false
  @impl true
  def run(%SourceFile{} = source_file, params) do
    ctx = Context.build(source_file, params, __MODULE__)

    if ignore_path?(source_file.filename, ctx.params.excluded_paths) do
      []
    else
      result = Credo.Code.prewalk(source_file, &walk/2, ctx)
      result.issues
    end
  end

  defp walk({:defmodule, meta, [mod | _]} = ast, ctx) do
    module_name = Name.full(mod)

    if has_namespace?(module_name, ctx.params.excluded_namespaces) do
      {ast, ctx}
    else
      module_dependencies = get_dependencies(ast, ctx.params.dependency_namespaces)

      {ast,
       put_issue(
         ctx,
         issue_for_module(module_dependencies, ctx.params.max_deps, ctx, meta, module_name)
       )}
    end
  end

  defp walk(ast, ctx) do
    {ast, ctx}
  end

  # Check if analyzed module path is within ignored paths
  defp ignore_path?(filename, excluded_paths) do
    directory = Path.dirname(filename)

    Enum.any?(excluded_paths, &matches?(directory, &1))
  end

  defp matches?(directory, %Regex{} = regex), do: Regex.match?(regex, directory)
  defp matches?(directory, path) when is_binary(path), do: String.starts_with?(directory, path)

  defp get_dependencies(ast, dependency_namespaces) do
    aliases = Module.aliases(ast)

    ast
    |> Module.modules()
    |> with_fullnames(aliases)
    |> filter_namespaces(dependency_namespaces)
  end

  # Resolve dependencies to full module names
  defp with_fullnames(dependencies, aliases) do
    dependencies
    |> Enum.map(&full_name(&1, aliases))
    |> Enum.uniq()
  end

  # Keep only dependencies which are within specified namespaces
  defp filter_namespaces(dependencies, namespaces) do
    Enum.filter(dependencies, &keep?(&1, namespaces))
  end

  defp keep?(_module_name, []), do: true

  defp keep?(module_name, namespaces), do: has_namespace?(module_name, namespaces)

  defp has_namespace?(module_name, namespaces) do
    Enum.any?(namespaces, &String.starts_with?(module_name, &1))
  end

  # Get full module name from list of aliases (if present)
  defp full_name(dep, aliases) do
    aliases
    |> Enum.find(&String.ends_with?(&1, dep))
    |> case do
      nil -> dep
      full_name -> full_name
    end
  end

  defp issue_for_module(deps, max_deps, ctx, meta, module_name) when length(deps) > max_deps do
    format_issue(
      ctx,
      message: "Module has too many dependencies: #{length(deps)} (max is #{max_deps})",
      trigger: module_name,
      line_no: meta[:line]
    )
  end

  defp issue_for_module(_, _, _, _, _), do: nil
end
